<?php
namespace s94\captcha;

use Exception;

/**
 * 点击图标位置类型的【图片验证码】
 */
class ImgCode{
    //配置
    /**【配置】生成的map图片尺寸
     * @var int 格式：[宽,高]
     */
    protected $mapSize = [800,450];
    /**【配置】map图片的内边距
     * @var int[] 格式：[上,右,下,左]
     */
    protected $mapPadding = [20,40,20,40];
    /**【配置】锚点图标尺寸，方形
     * @var int 边长
     */
    protected $pointSize = 90;
    /**【配置】锚点数量
     * @var int
     */
    protected $pointNum = 4;
    /**【配置】验证码标识,不同标识互不影响
     * @var string
     */
    protected $key = '';
    /**【配置】验证过期时间，为0表示永不过期
     * @var int 秒数
     */
    protected $timeout = 5*60;

    /**缓存到session的key值
     * @var string
     */
    protected static $sessionKey = 'ImgCode_session_key';
    /**背景图片列表
     * @var array 格式：[src,...]
     */
    protected $bgImages = [];
    /**锚点图标集图片
     * @var string
     */
    protected $pointMapImage = '';
    /**背景图片的尺寸
     * @var int[] 格式：[宽,高]
     */
    private $bgSize=[0,0];
    /**图标图片尺寸，方形
     * @var int 边长
     */
    private $icoSize;
    /**图标集中包含的图标数量
     * @var int
     */
    private $icoNum;
    /**背景图片GD对象
     * @var resource
     */
    private $bgObj;
    /**图片集GD对象
     * @var resource
     */
    private $pointObj;
    /**所有锚点的坐标
     * @var array 格式：[['left'=>锚点左边距背景左边的距离,'right'=>右边距左边,'top'=>上边距上边,'bottom'=>下边距上边],...]
     */
    private $pointsPosition = [];
    /**所有锚点对于图标的序列
     * @var array 格式：[int,...]
     */
    private $pointsIndex=[];

    /**
     * @throws Exception
     */
	public function __construct(){
        if (!count($this->bgImages)){
            $this->bgImages = [
                __DIR__.'/img/0.jpg',
                __DIR__.'/img/1.jpg',
                __DIR__.'/img/2.jpg',
                __DIR__.'/img/3.jpg',
                __DIR__.'/img/4.jpg',
                __DIR__.'/img/5.jpg',
                __DIR__.'/img/6.jpg',
                __DIR__.'/img/7.jpg',
                __DIR__.'/img/8.jpg',
            ];
        }
		$this->pointMapImage = $this->pointMapImage?: __DIR__.'/img/ico-map.png';
        //图标集图片
        $this->pointObj = @imagecreatefrompng($this->pointMapImage);
        self::assert($this->pointObj, '图标文件加载失败');
        $size = getimagesize($this->pointMapImage);
        $this->icoSize = $size[1];
        $this->icoNum = round($size[0]/$size[1]);
	}
	public function __destruct(){
		if ($this->bgObj) imagedestroy($this->bgObj);
		if ($this->pointObj) imagedestroy($this->pointObj);
	}
    /**随机背景图片资源
     * @return void
     * @throws Exception
     */
    private function randomBg()
    {
        $filepath = $this->bgImages[rand(0, count($this->bgImages)-1)];
        $this->bgObj = @imagecreatefromjpeg($filepath);
        self::assert($this->bgObj, '背景图片加载失败');
        $this->bgSize = getimagesize($filepath);
    }
    /**随机锚点坐标
     * @return void
     */
    private function randomPointPosition()
    {
        if(count($this->pointsPosition) >= $this->pointNum) return;
        //随机坐标
        $left = rand($this->mapPadding[3], $this->mapSize[0] - $this->mapPadding[1] - $this->pointSize);
        $top = rand($this->mapPadding[0], $this->mapSize[1] - $this->mapPadding[2] - $this->pointSize);
        $point = [
            'left'=>$left,'right'=>$left+$this->pointSize,
            'top'=>$top,'bottom'=>$top+$this->pointSize,
        ];
        //随机的锚点没有与之前的重复才加入到列表
        $isCover = false;
        foreach ($this->pointsPosition as $p){
            if ($point['right']<$p['left'] || $point['left']>$p['right'] ||
                $point['bottom']<$p['top'] || $point['top']>$p['bottom']) continue;
            $isCover = true;break;
        }
        if(!$isCover) $this->pointsPosition[] = $point;
        $this->randomPointPosition();
    }
    /**生成一组不重复的随机数
     * @param int $min 随机数最小值
     * @param int $max 随机数最大值
     * @param int $len 需要生成的数据数量
     * @return array
     * @throws Exception
     */
    private static function randUnique(int $min, int $max, int $len): array
    {
        $maxIndex = $max-$min;
        if ($min < 0 || $max < 0 || $len < 0 || $maxIndex+1 < $len) {
            throw new Exception('ImgCode.randUnique 参数无效');
        }
        $res = [];
        $arr = [];
        while ($min <= $max){
            $arr[] = $min++;
        }
        while ($len--){
            $index = rand(0, $maxIndex--);
            $res[] = array_splice($arr, $index, 1)[0];
        }
        return $res;
    }
    /**生成地图图片
     * @param resource $bgObj 背景图片资源
     * @param resource $pointObj 图标集图片资源
     * @return string 图片的base64数据
     */
    private function makeMap($bgObj, $pointObj): string
    {
        $width = $this->mapSize[0];
        $height = $this->mapSize[1];
        ob_start();
        $image = imagecreatetruecolor($width, $height);
        //印上背景
        imagecopyresized($image, $bgObj, 0, 0, 0, 0, $width, $height, $this->bgSize[0], $this->bgSize[1]);
        //按顺序和坐标印上图标
        foreach ($this->pointsPosition as $i=>$row) {
            $index = $this->pointsIndex[$i];
            $ico = imagecreatetruecolor($this->icoSize, $this->icoSize);
            imagealphablending($ico, false);
            imagesavealpha($ico , true);
            imagecopyresized($ico, $pointObj, 0, 0, $index*$this->icoSize, 0, $this->icoSize, $this->icoSize, $this->icoSize, $this->icoSize);
            $png_bg_color = imagecolorallocatealpha($ico , 0, 0, 0, 127);
            $ico = imagerotate($ico, rand(10,350), $png_bg_color);
            $padding = round((imagesx($ico) - $this->icoSize) / 2);
            imagecopyresized($image, $ico, $row['left'], $row['top'], $padding, $padding, $this->pointSize, $this->pointSize, $this->icoSize, $this->icoSize);
        }
        imagejpeg($image);
        imagedestroy($image);
        $content = ob_get_contents();
        ob_clean();
        return 'data:image/jpeg;base64,'.base64_encode($content);
    }
    /**生成目标图片
     * @param resource $bgObj 背景图片资源
     * @param resource $pointObj 图标集图片资源
     * @param mixed $all 是否生成所有锚点图标
     * @return string 图片的base64数据
     */
    private function makeTarget($bgObj, $pointObj, $all): string
    {
        $margin = $all ? [0,20] : [0,100];
        $pointNum = $all ? $this->pointNum : 1;
        ob_start();
        $w = $pointNum * ($this->icoSize+$margin[1]) + $margin[1];
        $h = $this->icoSize + 2 * $margin[0];
        $image = imagecreatetruecolor($w, $h);
        $bgWidth = $this->bgSize[0];
        //印上背景
        for ($l = 0; $l < $w; $l += $bgWidth) {
            imagecopy($image, $bgObj, $l, 0, 0, 0, $bgWidth, $h);
        }
        //按顺序印上图标
        for ($i=0; $i<$pointNum; $i++){
            $index = $this->pointsIndex[$i];
            imagecopy($image, $pointObj, $i*($this->icoSize+$margin[1])+$margin[1], $margin[0], $index*$this->icoSize, 0, $this->icoSize, $this->icoSize);
        }
        imagejpeg($image);
        imagedestroy($image);
        $content = ob_get_contents();
        ob_clean();
        return 'data:image/jpeg;base64,'.base64_encode($content);
    }
    //---------------------------------我是分割线---------------------------------
    /**
     * 断言，可以重写后加入自定义的异常处理
     * @param mixed $pass 是否通过，判断为false的时候，会抛出异常
     * @param string|Exception $e 抛出的异常
     * @return void
     * @throws Exception
     */
    protected static function assert($pass, $e)
    {
        if(!$pass) throw ($e instanceof Exception ? $e : new Exception($e));
    }
    /**
     * session缓存，可以重新后加入自定义的session机制
     * @param string|array $key
     * @param $default
     * @return mixed|void|null
     */
    protected static function session($key, $default=null)
    {
        if (session_status() != PHP_SESSION_ACTIVE) session_start();
        if (is_array($key)){
            foreach ($key as $k=>$v){
                $_SESSION[$k] = $v;
            }
        }else{
            return $_SESSION[$key] ?? $default;
        }
    }
    //--------------------------------我是分割线------------------------------
    /**设定生成的map图片尺寸
     * @param array $mapSize 格式：[宽,高]
     * @return $this
     * @throws Exception
     */
    public function setMapSize(array $mapSize): ImgCode
    {
        self::assert(count($mapSize)==2, '参数不合理');
        $this->mapSize = $mapSize;
        return $this;
    }
    /**设定map图片的内边距
     * @param int[] $mapPadding 格式：[上,右,下,左]
     * @return $this
     * @throws Exception
     */
    public function setMapPadding(array $mapPadding): ImgCode
    {
        self::assert(count($mapPadding)==4, '参数不合理');
        $this->mapPadding = $mapPadding;
        return $this;
    }
    /**设定锚点数量
     * @param int $pointNum
     * @return $this
     * @throws Exception
     */
    public function setPointNum(int $pointNum): ImgCode
    {
        self::assert($pointNum, '参数不合理');
        $this->pointNum = $pointNum;
        return $this;
    }
    /**设定锚点边长
     * @param int $pointSize
     * @return $this
     * @throws Exception
     */
    public function setPointSize(int $pointSize): ImgCode
    {
        self::assert($pointSize, '参数不合理');
        $this->pointSize = $pointSize;
        return $this;
    }
    /**设定key，在同一会话中，需要多个验证的时候，需要设置不同的key
     * @param string $key
     * @return $this
     */
    public function setKey(string $key): ImgCode
    {
        $this->key = $key;
        return $this;
    }
    /**设定过期时间
     * @param int $timeout 秒数，为0表示永不过期
     * @return $this
     */
    public function setTimeout(int $timeout): ImgCode
    {
        $this->timeout = $timeout;
        return $this;
    }
    /**检验坐标，传入几组坐标就验证几组
     * @param array $position 待检验的坐标数组，格式：['left'=>点击位置左边的占比[0-1],'top'=>点击位置上边的占比[0-1]]
     * @param string $key 验证码标示
     * @param mixed $delete 验证后是否删除缓存
     * @return bool
     * @throws Exception
     */
    public static function check(array $position, string $key='', $delete=false): bool
    {
        $key = self::$sessionKey.$key;
        $data = self::session($key);
        if ($delete) self::session([$key => null]);
        self::assert(is_array($data) && isset($data['position']) && isset($data['timeout']) && isset($data['size']) && ($data['timeout']==0||$data['timeout']>time()), '验证码已经过期，请重新获取');

        foreach ($position as $i=>$row){
            self::assert(isset($row['left']) && isset($row['top']), '坐标参数格式错误');
            $point = $data['position'][$i];
            $left = $row['left'] * $data['size'][0];
            $top = $row['top'] * $data['size'][1];

            $betweenX = $point['left']<=$left && $left<=$point['right'];
            $betweenY = $point['top']<=$top && $top<=$point['bottom'];
            self::assert($betweenX&&$betweenY, '验证码验证未通过');
        }
        return true;
    }
    /**生成图片验证码
     * @param mixed $all 是否需要按顺序点
     * @return array 图片的base64数据，格式：['map'=>地图图片,'target'=>目标图片]
     * @throws Exception
     */
    public function make($all=false): array
    {
        //背景图片
        $this->randomBg();
        //随机锚点坐标
        $this->randomPointPosition();
        //随机锚点图标序列
        $this->pointsIndex = self::randUnique(0, $this->icoNum-1, $this->pointNum);
        //缓存数据
        $data = [
            'position' => $this->pointsPosition,
            'timeout'=> $this->timeout? time()+$this->timeout : 0,
            'size'=>$this->mapSize,
        ];
        $key = self::$sessionKey.$this->key;
        $this->session([$key => $data]);
        //生成map图片
        $mapImage = $this->makeMap($this->bgObj, $this->pointObj);
        $targetImage = $this->makeTarget($this->bgObj, $this->pointObj, $all);
        return ['map'=>$mapImage, 'target'=>$targetImage];
    }
}
